Effective Java 2.0_中文版_Item 5

文章作者:Tyan
博客:noahsnail.com | CSDN | 简书

Item 5: 避免创建不必要的对象

每次需要一个对象时,与创建一个新的功能相同的对象相比,复用一个对象经常是合适的。复用更快更流行。如果一个对象是不变的,那它总是可以复用。(Item 15)

作为一个不该做什么的极端例子,请看下面这种情况:

1
String s = new String("stringette"); // DON'T DO THIS!

这条语句每次执行时都会创建一个新的String实例,这些对象的创建都是没必要的。String构造函数的参数"stringette"本身就是一个String实例,在功能上与构造函数创建的所有对象都是等价的。如果这种用法出现在一个循环或一个频繁调用的方法中,会创建出成千上万的不必要的实例。

改进版本如下:

1
String s = "stringette";

这个版本使用单个的String实例,而不是每次执行时创建一个新实例。此外,它保证了运行在虚拟中包含同样字符串的任何其它代码都可以复用这个对象[JLS, 3.10.5]。

除了复用不可变对象之外,如果你知道可变对象不会被修改,你也可以复用可变对象。下面是一个比较微妙,更为常见反面例子。它包含可变的Date对象,这些Date对象一旦计算出来就不再修改。这个类对人进行了建模,其中有一个isBabyBoomer方法用来区分这个人是否是一个“baby boomer(生育高峰时的小孩)”,换句话说就是判断这个人是否出生在1946年到1964年之间:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class Person {

private final Date birthDate;

// Other fields, methods, and constructor omitted
// DON'T DO THIS!
public boolean isBabyBoomer() {
// Unnecessary allocation of expensive object
Calendar gmtCal = Calendar.getInstance(TimeZone.getTimeZone("GMT"));
gmtCal.set(1946, Calendar.JANUARY, 1, 0, 0, 0);
Date boomStart = gmtCal.getTime();
gmtCal.set(1965, Calendar.JANUARY, 1, 0, 0, 0);
Date boomEnd = gmtCal.getTime();
return birthDate.compareTo(boomStart) >= 0 && birthDate.compareTo(boomEnd) < 0;
}
}

每次调用时,isBabyBoomer方法都会创建一个Calendar实例,一个TimeZone实例和两个Date实例,这是不必要的。下面的版本用静态初始化避免了这种低效率的问题:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class Person {
private final Date birthDate;

// Other fields, methods, and constructor omitted

/**
* The starting and ending dates of the baby boom.
*/
private static final Date BOOM_START;
private static final Date BOOM_END;
static {
Calendar gmtCal = Calendar.getInstance(TimeZone.getTimeZone("GMT"));
gmtCal.set(1946, Calendar.JANUARY, 1, 0, 0, 0);
BOOM_START = gmtCal.getTime();
gmtCal.set(1965, Calendar.JANUARY, 1, 0, 0, 0);
BOOM_END = gmtCal.getTime();
}

public boolean isBabyBoomer() {
return birthDate.compareTo(BOOM_START) >= 0
&& birthDate.compareTo(BOOM_END) < 0;
}
}

Person类的改进版本只在初始化时创建CalendarTimeZoneDate实例一次,而不是每次调用isBabyBoomer方法都创建它们。如果isBabyBoomer方法被频繁调用的话,这样做在性能上会有很大提升。在我的机器上,最初的版本一千万次调用要花费32,000毫秒,而改进版本只花了130毫秒,比最初版本快了大约250倍。不仅性能改善了,代码也更清晰了。将boomStartboomEnd从局部变量变为static final字段,很明显是将它们看作常量,代码也更容易理解。从整体收益来看,这种优化的节约并不总是这么戏剧性的,因为Calendar实例创建的代价是非常昂贵的。

如果初始化Person类的改进版本,但从不调用它的isBabyBoomer方法,BOOM_STARTBOOM_END字段的初始化就是不必要的。可以通过延迟初始化(当需要时再初始化)这些字段(Item 71)来消除这些不必要的初始化,当第一次调用isBabyBoomer方法时再进行初始化,但不推荐这样做。延迟初始化是常有的事,它的实现是非常复杂的,除了我们已有的性能提升之外,延迟初始化不可能引起明显的性能提升(Item 55)。

在本条目前面的例子中,很明显问题中的对象可以复用,因为它们在初始化之后没有被修改。但在其它的情况下它就不那么明显了。考虑一个适配器的情况[Gamma95, p. 139],也称之为视图。适配器是代理支持对象的对象,为支持对象提供了一个可替代的接口。由于适配器除了它的支持对象之外没有别的状态,因此没必要创建多个给定对象的适配器实例。

在JDK 1.5中有一种新的方式来创建不必要对象。它被称为自动装箱,它允许程序员混合使用基本类型和它们的包装类型,JDK会在需要时自动装箱和拆箱,自动装箱虽然模糊但不能去除基本类型和包装类之间的区别。它们在语义上有稍微的不同,但不是轻微的性能差异(Item 49)。看一下下面的程序,计算所有正数int值的总和。为了计算这个,程序必须使用long类型,因为int不能容纳所有正int值的和:

1
2
3
4
5
6
7
8
// Hideously slow program! Can you spot the object creation?
public static void main(String[] args) {
Long sum = 0L;
for (long i = 0; i < Integer.MAX_VALUE; i++) {
sum += i;
}
System.out.println(sum);
}

这个程序算出了正确答案,但由于一个字符的错误,它运行的更慢一些。变量sum声明为Long而不是long,这意味着程序构建了大约2^31不必要的Long实例(基本上每次long i加到Long sum上都要创建一个)。将sumLong声明为long之后,在我机器上运行时间从43秒降到了6.8秒。结论很明显:使用基本类型优先于包装类,当心无意的自动装箱

不该将本条目误解成暗示创建对象是昂贵的,应该避免创建对象。恰恰相反,创建和回收构造函数做很少显式工作的小对象是非常廉价的,尤其是在现代的JVM实现上。创建额外的对象来增强程序的清晰性,简洁性,或能力通常是一件好事。

相反的,通过维护你自己的对象池来避免创建对象是一个坏主意,除非对象池中的对象是极度重量级的。真正证明对象池的对象经典例子是数据库连接。建立连接的代价是非常大的,因此复用这些对象是很有意义的。数据库许可可能也限制你使用固定数目的连接。但是,通常来说维护你自己的对象池会使你的代码很乱,增加内存占用,而且损害性能。现代JVM实现有高度优化的垃圾回收机制,维护轻量级对象很容易比对象池做的更好。

与本条目对应的是Item 39 保护性拷贝。Item 5 声称,『不要创建一个新的对象,当你应该复用一个现有的对象时』,而Item 39 声称,『不要重用一个现有的对象,当你应该创建一个新的对象时』。注意,当保护性拷贝时复用一个对象的代价要远大于创建一个不必要的重复对象的代价。当需要时没有创建一个保护性拷贝可能导致潜在的错误和安全漏洞;创建不必要的对象只会影响程序风格及性能。

如果有收获,可以请我喝杯咖啡!